fix(mobile): move the thread sync indicator into the header - #5000
fix(mobile): move the thread sync indicator into the header#5000carTloyal123 wants to merge 1 commit into
Conversation
The "Syncing threads..." indicator flashed in and out above the thread list. Two causes, both fixed here. It lived inside the scroll view. WorkspaceConnectionStatus was rendered as ListHeaderComponent (row 0) in HomeScreen and ThreadNavigationSidebar, so every sync flip mounted or unmounted a row and reflowed everything below it. It now renders as a header button instead, and the Android floating overlay it duplicated is gone. The signal itself strobes. hasSynchronizingShell re-enters "synchronizing" on every websocket resubscribe and on a 250ms retry cycle, so relocating the UI alone would have kept the blinking. The raw state is now reduced to a tone (offline / error / disconnected / connecting / syncing / idle) and settled before it renders: transient tones must persist 400ms — longer than the retry cycle, so self-resolving blips never appear — and stay 900ms once shown. Faults bypass both delays, since delaying those would hide a problem worth seeing. The button keeps its slot when idle (an empty box of the same size), so neighbouring header controls never shift. Wired into the Android home header, the iOS native header items, and the split-view sidebar. Tapping still opens environment settings. The full-page empty state keeps its inline pill on purpose: with no threads on screen it is the only thing explaining why the page is blank, and there is nothing below it to reflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Medium
The native iOS sidebar header shifts its filter and settings controls horizontally whenever the sync tone transitions between idle and any visible tone. When syncTone is idle, workspaceSyncToneSymbol returns null, and createSidebarHeaderItems omits the sync bar item entirely; when the tone changes to connecting, error, etc., the item reappears. This inserts and removes the leading navigation bar item on each sync-state flip, causing the same header-layout movement the PR is intended to eliminate. The React header avoids this by always rendering a fixed-size WorkspaceSyncStatusButton slot (an empty box when idle). Consider rendering a fixed native bar item for the idle tone as well, so the filter/settings controls stay in place.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx around line 39:
The native iOS sidebar header shifts its filter and settings controls horizontally whenever the sync tone transitions between `idle` and any visible tone. When `syncTone` is `idle`, `workspaceSyncToneSymbol` returns `null`, and `createSidebarHeaderItems` omits the sync bar item entirely; when the tone changes to `connecting`, `error`, etc., the item reappears. This inserts and removes the leading navigation bar item on each sync-state flip, causing the same header-layout movement the PR is intended to eliminate. The React header avoids this by always rendering a fixed-size `WorkspaceSyncStatusButton` slot (an empty box when idle). Consider rendering a fixed native bar item for the idle tone as well, so the filter/settings controls stay in place.
| // React headers: each change re-enters navigation.setOptions. | ||
| const syncTone = useSettledWorkspaceSyncTone(props.catalogState); | ||
| const syncSymbol = workspaceSyncToneSymbol(syncTone); | ||
| const syncLabel = workspaceConnectionStatusLabel(props.catalogState); |
There was a problem hiding this comment.
🟡 Medium home/HomeHeader.tsx:324
The native iOS sync button's accessibility label (syncLabel) is derived from raw props.catalogState, while its icon (syncSymbol) is derived from the settled syncTone. During the 900 ms minimum-visible hold after syncing completes, the button still displays the syncing icon but workspaceConnectionStatusLabel already returns "Not connected", so VoiceOver announces a status that contradicts the visible indicator. Derive the label from the same settled tone as the icon, or settle the label alongside it.
Also found in 2 other location(s)
apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx:70
The button's visible state comes from settled
tone, but its accessibility label is recomputed from the rawprops.state. During the 900 ms minimum-visible period after syncing has completed, the button still renders a spinner whileworkspaceConnectionStatusLabelcommonly returnsNot connected; analogous rapid transitions can label one settled status as another. Screen-reader users therefore hear a status that contradicts the rendered indicator. Derive the label from the settled tone or settle the label alongside it.
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx:1040
syncLabelis derived from the rawcatalogStatewhilesyncSymbolis derived from the settled tone. During the 900 ms minimum-visible hold after connecting/syncing resolves, the native header still displays the sync icon but its accessibility label has already changed (typically to"Not connected"), so VoiceOver announces a state that does not match the visible settled status. Derive the label from the same settled tone/state as the icon.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/home/HomeHeader.tsx around line 324:
The native iOS sync button's accessibility label (`syncLabel`) is derived from raw `props.catalogState`, while its icon (`syncSymbol`) is derived from the settled `syncTone`. During the 900 ms minimum-visible hold after syncing completes, the button still displays the syncing icon but `workspaceConnectionStatusLabel` already returns `"Not connected"`, so VoiceOver announces a status that contradicts the visible indicator. Derive the label from the same settled tone as the icon, or settle the label alongside it.
Also found in 2 other location(s):
- apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx:70 -- The button's visible state comes from settled `tone`, but its accessibility label is recomputed from the raw `props.state`. During the 900 ms minimum-visible period after syncing has completed, the button still renders a spinner while `workspaceConnectionStatusLabel` commonly returns `Not connected`; analogous rapid transitions can label one settled status as another. Screen-reader users therefore hear a status that contradicts the rendered indicator. Derive the label from the settled tone or settle the label alongside it.
- apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx:1040 -- `syncLabel` is derived from the raw `catalogState` while `syncSymbol` is derived from the settled tone. During the 900 ms minimum-visible hold after connecting/syncing resolves, the native header still displays the sync icon but its accessibility label has already changed (typically to `"Not connected"`), so VoiceOver announces a state that does not match the visible settled status. Derive the label from the same settled tone/state as the icon.
| return; | ||
| } | ||
|
|
||
| if (settlement.kind === "apply") { |
There was a problem hiding this comment.
🟡 Medium home/use-settled-workspace-sync-tone.ts:100
When a non-transient fault (offline/error/disconnected) replaces a shown spinner, holdUntilRef keeps the stale spinner deadline. If the fault later clears to idle before that deadline expires, planWorkspaceSyncToneSettlement reuses the obsolete hold and schedules the idle transition hundreds of milliseconds in the future — so the header keeps showing the already-resolved fault for up to 900 ms after recovery. Reset holdUntilRef to 0 when applying an immediate non-transient fault, since the minimum-visible hold only applies to transient tones.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/home/use-settled-workspace-sync-tone.ts around line 100:
When a non-transient fault (`offline`/`error`/`disconnected`) replaces a shown spinner, `holdUntilRef` keeps the stale spinner deadline. If the fault later clears to `idle` before that deadline expires, `planWorkspaceSyncToneSettlement` reuses the obsolete hold and schedules the `idle` transition hundreds of milliseconds in the future — so the header keeps showing the already-resolved fault for up to 900 ms after recovery. Reset `holdUntilRef` to 0 when applying an immediate non-transient fault, since the minimum-visible hold only applies to transient tones.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6330f6b. Configure here.
| if (state.hasPendingShellSnapshot) return "syncing"; | ||
| if (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) return "disconnected"; | ||
| return "idle"; | ||
| } |
There was a problem hiding this comment.
Reconnect misclassified as error tone
High Severity
workspaceSyncTone returns error whenever connectionError is set, before it checks for an in-progress reconnect. Reconnecting environments normally carry lastFailure as connectionError, so active reconnects become a non-transient error triangle instead of a settled connecting spinner, and the label still says reconnecting.
Reviewed by Cursor Bugbot for commit 6330f6b. Configure here.
| const remainingHold = input.holdUntil - input.now; | ||
| return remainingHold <= 0 | ||
| ? { kind: "apply", tone: "idle" } | ||
| : { kind: "schedule", tone: "idle", delayMs: remainingHold }; |
There was a problem hiding this comment.
Fault clear delayed by stale hold
Medium Severity
Clearing to idle always respects holdUntil, even when the visible tone is already a real fault. After a spinner sets that timestamp, a later offline/error/disconnected indicator can linger for the leftover hold once the fault resolves, instead of dismissing immediately.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6330f6b. Configure here.
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. Multiple unresolved review comments identify substantive bugs: a high-severity logic error where reconnects are misclassified as error states, accessibility label mismatches with the visual indicator, and layout shift issues on native iOS. These issues should be addressed before merging. You can customize Macroscope's approvability policy. Learn more. |
|
Superseded by #5023, which fixes the same bug with a better approach. This PR moved the status into a header icon button. That removed the flash, but traded away the full status text — you lost "Reconnecting to " in favour of an ambiguous glyph, and a static sync icon does not read as "data is moving". #5023 keeps the original status bar and its wording, and instead pins it above the list so it can no longer mount/unmount and reflow the rows. At rest it reports Closing rather than force-pushing, since it is a different approach rather than an iteration on this one. |


What changed
The "Syncing threads..." indicator flashed in and out above the thread list. There were two independent causes, and this fixes both.
1. It lived inside the scroll view
WorkspaceConnectionStatuswas rendered asListHeaderComponent— literally row 0 — in bothHomeScreenandThreadNavigationSidebar. Every sync-state flip mounted or unmounted a row, which flashed the indicator and reflowed every row beneath it.It now renders as a header button instead. The Android floating overlay that duplicated it is gone too.
2. The signal itself strobes
Moving the UI alone would not have stopped the blinking.
hasSynchronizingShellre-enters"synchronizing"on every websocket resubscribe and retries expected failures on a 250ms cycle, so even a healthy connection produces a stream of sub-second sync blips.The raw state is now reduced to a tone (
offline/error/disconnected/connecting/syncing/idle) and settled before it renders:The settling decision is a pure function (
planWorkspaceSyncToneSettlement) so the timing behaviour is unit-tested rather than implicit in a hook.Before / after
Captured on iOS Simulator (iPhone 17 Pro, iOS 26.5) against a local server with six seeded threads. Each pair is the same screen: connected and idle, then with the environment disconnected.
Before: the status becomes row 0 of the list. "Tidy up mobile header spacing" is pushed from y≈307 to y≈422 — every row below it shifts down, then snaps back when the state clears.
After: the status is an icon in the header capsule next to
•••. The list does not move at all — the first row stays at y≈307 in both states.Screen recordings of the same disconnect/reconnect cycle:
(Media lives on a separate branch so it stays out of this diff.)
Details worth noting
ActivityIndicator.optionsVersionhad to be extended — native header factories are stabilized, so a tone captured inside one can't change the options signature on its own and the icon would never update.Verification
tsc --noEmitcleanvp lintclean on changed filesBranched off current
main. Split from #4999 so the two unrelated fixes stay reviewable separately.